media_pp\elements\source\capture\windows/dxgi_capture_source.rs
1use std::{
2 ffi::c_void,
3 sync::Arc,
4 time::{Duration, Instant},
5};
6
7use crate::pp_log::{PpLog, pp_error, pp_info};
8use ffmpeg_next as ffmpeg;
9use thiserror::Error as ThisError;
10use windows::{
11 Win32::{
12 Foundation::{HMODULE, POINT, RECT},
13 Graphics::{
14 Direct3D::D3D_DRIVER_TYPE_UNKNOWN,
15 Direct3D11::{
16 D3D11_BIND_FLAG, D3D11_BIND_SHADER_RESOURCE, D3D11_BOX, D3D11_CPU_ACCESS_READ,
17 D3D11_CREATE_DEVICE_FLAG, D3D11_MAP_READ, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
18 D3D11_USAGE_DEFAULT, D3D11_USAGE_STAGING, D3D11CreateDevice, ID3D11Device,
19 ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D,
20 },
21 Dxgi::{
22 Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC},
23 CreateDXGIFactory1, DXGI_ERROR_ACCESS_LOST, DXGI_ERROR_WAIT_TIMEOUT,
24 DXGI_OUTDUPL_FRAME_INFO, DXGI_OUTDUPL_POINTER_SHAPE_INFO,
25 DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR,
26 DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR,
27 DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME, IDXGIAdapter1, IDXGIDevice,
28 IDXGIFactory1, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource,
29 },
30 },
31 },
32 core::Interface,
33};
34
35use crate::{
36 buffer::MediaBuffer,
37 bus::{Bus, BusEvent},
38 control::{ControlReceiver, drain_control},
39 element::{Element, ElementType, Source, SourceElement, element_pp_log},
40 elements::filter::decoder::d3d11va_decoder::wrap_d3d11_texture,
41 error::Result,
42 pad::SrcPad,
43 pool::UnboundObjectPool,
44 schedule::PeriodicSchedule,
45};
46
47/// How often [`DxgiCaptureSource::run`]'s poll loop re-checks
48/// `drain_control`/whether it's time to emit, even mid-wait for the next
49/// real desktop change — bounds `Stop` latency at very low configured
50/// [`DxgiCaptureOptions::fps`] values, where "wait until the next tick" on
51/// its own could otherwise be a long, unresponsive block. Same idea as
52/// [`crate::queue::Queue`]'s own `STOP_POLL_INTERVAL`.
53const POLL_GRANULARITY: Duration = Duration::from_millis(100);
54
55/// Errors specific to `DxgiCaptureSource`. Converts into the crate-wide
56/// `Error` via `?` (see [`crate::error::Error`]).
57#[derive(Debug, ThisError)]
58pub enum DxgiCaptureSourceError {
59 #[error("windows error: {0}")]
60 Windows(#[from] windows::core::Error),
61
62 #[error("no DXGI output at index {0} (across every adapter)")]
63 NoSuchOutput(u32),
64
65 /// `DXGI_ERROR_ACCESS_LOST` specifically, broken out of the generic
66 /// [`DxgiCaptureSourceError::Windows`] variant because it's the single
67 /// most common *recoverable* failure mode for desktop duplication —
68 /// a lock screen, a UAC prompt, a display mode change, or a
69 /// fullscreen-exclusive app/overlay stealing the duplication lock all
70 /// surface this way. Same "fail fast, caller rebuilds a fresh one"
71 /// contract [`crate::elements::RtspSource`] already documents: this
72 /// element doesn't retry internally, callers that want to survive a
73 /// lock-screen cycle watch for this specific error and call
74 /// [`DxgiCaptureSource::open`] again.
75 #[error("DXGI_ERROR_ACCESS_LOST — desktop duplication needs to be reopened")]
76 AccessLost,
77
78 #[error("DxgiCaptureSource doesn't support seeking a live capture")]
79 SeekUnsupported,
80
81 #[error("CaptureArea::Region {0:?} doesn't overlap any display output")]
82 RegionOutsideDesktop(CaptureRect),
83
84 /// See [`CaptureArea::Region`]'s own docs on why this is a hard
85 /// failure rather than an automatic CPU-bridged fallback.
86 #[error(
87 "CaptureArea::Region spans outputs on more than one GPU adapter — \
88 zero-copy compositing across adapters isn't supported"
89 )]
90 RegionSpansMultipleAdapters,
91
92 #[error("include_cursor isn't supported when CaptureArea::Region spans more than one output")]
93 CursorUnsupportedForRegion,
94}
95
96/// How [`DxgiCaptureSource::open`] captures each frame — see
97/// [`DxgiCaptureOptions::capture_mode`].
98#[derive(Debug, Clone)]
99pub enum CaptureMode {
100 /// The original behavior: `AcquireNextFrame`'s resource is copied into
101 /// a CPU-readable staging texture, `Map`ped, and copied row-by-row
102 /// into a plain `Pixel::BGRA` CPU frame. No external device required —
103 /// `open` creates its own, internal to this element, from the chosen
104 /// output's own adapter.
105 ///
106 /// `include_cursor` composites the mouse cursor onto every emitted
107 /// frame. Off by default: the base capture path (desktop pixels only)
108 /// needs no extra work, and most consumers (recording, streaming a
109 /// presentation) don't want the cursor baked in at all. Only exists on
110 /// this variant — cursor compositing is CPU-side pixel blending
111 /// (`composite_cursor`), which has nothing to run against under
112 /// [`CaptureMode::Gpu`], where the captured image never touches the
113 /// CPU at all; putting the field here instead of as a separate
114 /// `DxgiCaptureOptions` flag makes that combination unrepresentable
115 /// rather than a runtime error to guard against. Also unsupported
116 /// (a hard `open`-time error, see
117 /// [`DxgiCaptureSourceError::CursorUnsupportedForRegion`]) when
118 /// [`DxgiCaptureOptions::area`] is a [`CaptureArea::Region`] spanning
119 /// more than one output — see that variant's own docs on why.
120 Cpu { include_cursor: bool },
121 /// Captures straight to a GPU-resident frame tagged `Pixel::D3D11`
122 /// (BGRA — desktop content has no reason to go through YUV) — no
123 /// `Map`, no CPU pixel copy at all, just GPU-side `CopyResource`/
124 /// `CopySubresourceRegion` calls (each contributing output's
125 /// duplication resource -> this element's own per-output "latest
126 /// capture" texture, then those -> a fresh per-emission composite
127 /// texture every tick, so an in-flight pushed frame's content can't
128 /// change under whatever's still reading it — same reasoning
129 /// [`crate::elements::D3d11Upload`] documents for building a fresh
130 /// texture per call rather than reusing one).
131 ///
132 /// Unlike [`CaptureMode::Cpu`], this variant carries no device of its
133 /// own to inject: `open` always builds the device itself, from
134 /// whichever adapter [`DxgiCaptureOptions::area`] actually selects
135 /// (the *only* place that resolves "which adapter" — see
136 /// `resolve_area`), and hands it back as `open`'s own return value
137 /// for the caller to reuse. That's the one `ID3D11Device` every other
138 /// D3D11 element sharing this capture's output should be built from
139 /// (e.g. `render_common::D3d11GpuContext::new(Some(device))`) — for
140 /// `open`'s own zero-copy path to mean anything, see
141 /// [`crate::elements::D3d11Renderer`]'s own docs on why. Taking a
142 /// caller-supplied device here instead would only reopen the exact
143 /// adapter-mismatch problem this design avoids: two independently
144 /// resolved "which adapter" answers that would need to be checked
145 /// against each other instead of structurally being the same one.
146 ///
147 /// No cursor option — see [`CaptureMode::Cpu`]'s own docs on why.
148 Gpu,
149}
150
151/// A capture region in absolute virtual-desktop pixel coordinates — the
152/// same origin/units Win32 itself uses for multi-monitor layout
153/// (`GetSystemMetrics(SM_XVIRTUALSCREEN)`, `MONITORINFOEX::rcMonitor`,
154/// `DXGI_OUTPUT_DESC::DesktopCoordinates`), not local to any one monitor.
155/// See [`CaptureArea::Region`].
156#[derive(Debug, Clone, Copy)]
157pub struct CaptureRect {
158 pub x: i32,
159 pub y: i32,
160 pub width: u32,
161 pub height: u32,
162}
163
164/// Which portion of the desktop [`DxgiCaptureSource::open`] duplicates —
165/// see [`DxgiCaptureOptions::area`].
166#[derive(Debug, Clone, Copy)]
167pub enum CaptureArea {
168 /// The `output_index`'th output's entire desktop — a flat index
169 /// across every adapter's every output, in enumeration order
170 /// (adapter 0's outputs, then adapter 1's, ...) — "monitor 0",
171 /// "monitor 1", regardless of which GPU each is attached to. `0` is
172 /// whatever Windows considers the first output of the first adapter,
173 /// not necessarily the primary monitor.
174 ///
175 /// The simple case: exactly one `IDXGIOutputDuplication`, no
176 /// cropping, no compositing.
177 Output { output_index: u32 },
178 /// An arbitrary rectangle in absolute virtual-desktop coordinates —
179 /// for callers that only know "this screen area" (e.g. a
180 /// user-dragged region-selection UI), not which monitor index owns
181 /// it. May overlap more than one output: `open` resolves every
182 /// output the rectangle intersects and opens one
183 /// `IDXGIOutputDuplication` per contributing output, then stitches
184 /// each output's contribution into one composite
185 /// `rect.width x rect.height` image every capture tick —
186 /// `CopySubresourceRegion` under [`CaptureMode::Gpu`], a plain
187 /// per-row memory copy under [`CaptureMode::Cpu`] — placing each
188 /// piece at its correct offset, no scaling or blending.
189 ///
190 /// **Every intersected output must share the same adapter.** Desktop
191 /// Duplication resources can't be copied directly across
192 /// `ID3D11Device`s from different adapters without a CPU round trip
193 /// — `open` checks every intersected output's adapter *before*
194 /// opening any duplication, so a rejected region never partially
195 /// opens anything, and fails outright
196 /// ([`DxgiCaptureSourceError::RegionSpansMultipleAdapters`]) rather
197 /// than silently falling back to a CPU bridge for the mismatched
198 /// output — same "hard, loud failure, never a silent auto-copy"
199 /// reasoning as `D3d12Renderer`'s own device-mismatch guard.
200 ///
201 /// `include_cursor` (see [`CaptureMode::Cpu`]) is only valid when the
202 /// region resolves to a single output —
203 /// [`DxgiCaptureSourceError::CursorUnsupportedForRegion`] otherwise.
204 /// The cursor can legitimately straddle a monitor boundary inside a
205 /// stitched composite; handling that correctly isn't done, simplest
206 /// to reject rather than silently draw it wrong.
207 Region(CaptureRect),
208}
209
210/// Construction-time options for [`DxgiCaptureSource::open`].
211#[derive(Debug, Clone)]
212pub struct DxgiCaptureOptions {
213 /// Which output(s) to capture from — see [`CaptureArea`].
214 pub area: CaptureArea,
215 /// The constant rate frames are emitted at — see [`DxgiCaptureSource`]'s
216 /// own docs on why this is a fixed output rate (like
217 /// [`crate::elements::TestVideoSource::new`]'s `framerate`), not a cap
218 /// on an otherwise irregular one. `30` by default, matching
219 /// `TestVideoSource`'s own default.
220 pub fps: u32,
221 /// CPU (the original behavior) or GPU (zero-copy) capture — see
222 /// [`CaptureMode`]. `CaptureMode::Cpu { include_cursor: false }` by
223 /// default, so existing callers building `DxgiCaptureOptions { ..
224 /// ..Default::default() }` keep today's behavior unchanged.
225 pub capture_mode: CaptureMode,
226}
227
228impl Default for DxgiCaptureOptions {
229 fn default() -> Self {
230 Self {
231 area: CaptureArea::Output { output_index: 0 },
232 fps: 30,
233 capture_mode: CaptureMode::Cpu {
234 include_cursor: false,
235 },
236 }
237 }
238}
239
240/// One cached mouse cursor shape — refreshed only when
241/// `DXGI_OUTDUPL_FRAME_INFO::PointerShapeBufferSize` says it changed
242/// (the shape rarely changes frame-to-frame; re-fetching it on every
243/// frame would be wasted work).
244struct CursorShape {
245 kind: u32,
246 width: u32,
247 height: u32,
248 pitch: u32,
249 data: Vec<u8>,
250}
251
252/// One contributing output's own duplication plus this element's copy of
253/// its capture, and where that output's portion belongs in the final
254/// composite image. Exactly one of these exists under
255/// [`CaptureArea::Output`]; [`CaptureArea::Region`] has one per output it
256/// overlaps.
257struct CaptureUnit {
258 duplication: IDXGIOutputDuplication,
259 /// This output's own full-resolution capture — CPU-readable
260 /// (`D3D11_USAGE_STAGING`) under [`CaptureMode::Cpu`], GPU-only
261 /// (`D3D11_USAGE_DEFAULT`, no bind flags) under [`CaptureMode::Gpu`]
262 /// — only the fresh composite texture
263 /// [`DxgiCaptureSource::emit_frame_gpu`] builds needs to be
264 /// shader-bindable, not this one. Sized to this output's own
265 /// resolution, not the final composite's — see `source_box` for the
266 /// (possibly smaller) piece of it actually used.
267 staging_texture: ID3D11Texture2D,
268 /// The sub-rectangle of `staging_texture`, in this output's own
269 /// local pixel coordinates, that actually falls inside the
270 /// requested capture area. The whole texture under
271 /// [`CaptureArea::Output`]; a crop under [`CaptureArea::Region`] —
272 /// every pixel outside this box was requested by nobody, no reason
273 /// to copy it into the composite.
274 source_box: D3D11_BOX,
275 /// Where `source_box`'s pixels land in the final composite image.
276 dest_x: u32,
277 dest_y: u32,
278 /// Whether this unit has captured at least one real image yet — the
279 /// element as a whole is ready to emit only once every unit's own
280 /// flag is `true` (see [`DxgiCaptureSource::all_captured`]), so a
281 /// freshly opened multi-output region never emits with part of the
282 /// composite still blank.
283 has_captured: bool,
284}
285
286/// Captures the desktop via Windows' DXGI Desktop Duplication API
287/// (`IDXGIOutputDuplication`) — GStreamer's `d3d11screencapturesrc`
288/// equivalent. One src pad, pushing `Pixel::BGRA` frames (no internal
289/// color conversion — same division of labor as every other source in
290/// this crate: chain a [`crate::elements::Scaler`] downstream if
291/// something needs YUV420P, e.g. [`crate::elements::D3d12Renderer`]'s
292/// CPU-upload path or [`crate::elements::SwEncoder`]).
293///
294/// Emits at a **constant** rate — [`DxgiCaptureOptions::fps`] — not one
295/// push per real desktop change. An earlier version of this pushed
296/// variable-rate (VFR): a real wall-clock pts per actual change, nothing
297/// in between. That turned out to cause real problems, both for muxing
298/// (most consumers assume something closer to a steady rate) and,
299/// concretely, for live rendering: `D3d12Renderer` presents on a
300/// vsync-locked swap chain (`Present(1, ..)`) that only ever shows the
301/// *latest* submitted frame each tick, silently dropping anything else
302/// queued behind it — submission timing straight off an irregular VFR
303/// source has no relationship to that vsync grid, so real changes would
304/// unpredictably race into the same tick (one silently discarded) or
305/// land in a gap (stale frame held an extra tick), which is visible
306/// judder even though the *average* rate was exactly right.
307///
308/// So instead: this element always keeps the most recently captured
309/// desktop image on hand, and [`SourceElement::run`]'s own loop emits it
310/// — the same one again if nothing changed since the last tick — at a
311/// steady `1 / fps` cadence, entirely on the one thread `run()` already
312/// has (no extra threads spawned; see this crate's own "elements never
313/// spawn their own threads" rule). Same shape as
314/// [`crate::elements::TestVideoSource`]: [`DxgiCaptureSource::time_base`]
315/// is `1 / fps` and `pts` is a plain incrementing tick counter, one per
316/// *emitted* frame, not per real capture.
317///
318/// Confirmed (`examples/render/screen_capture`, with and without a
319/// downstream [`crate::elements::Pacer`]) that this constant-rate,
320/// drift-free schedule is what actually mattered — not whether a
321/// separate `Pacer` stage exists. The VFR version needed one to paper
322/// over its own irregular submission timing; once emission here is
323/// steady and drift-free, `Scaler`'s modest, fairly consistent per-frame
324/// conversion cost isn't enough on its own to reintroduce the same vsync
325/// misalignment, so a straight `DxgiCaptureSource -> Scaler -> D3d12Renderer`
326/// chain stays smooth with no `Pacer` at all. `Pacer` remains genuinely
327/// useful for other reasons (multi-stream sync against a shared `Clock`,
328/// or a stage with real per-frame variance like `SwEncoder`), just not
329/// load-bearing here purely for vsync alignment the way it first
330/// appeared to be.
331///
332/// Deliberately does **not** retry internally on `DXGI_ERROR_ACCESS_LOST`
333/// (lock screen, UAC prompt, display mode change, ...) — same "fail fast,
334/// caller rebuilds" contract as [`crate::elements::RtspSource`]; watch for
335/// [`DxgiCaptureSourceError::AccessLost`] and call
336/// [`DxgiCaptureSource::open`] again.
337///
338/// Runs until `Stop` — never reaches `Eos` on its own, same as
339/// `TestVideoSource` (there's no natural end to a live desktop capture).
340///
341/// May capture from more than one output at once — see
342/// [`CaptureArea::Region`] — in which case every field below that used
343/// to describe "the" duplication instead describes one `CaptureUnit`
344/// per contributing output.
345pub struct DxgiCaptureSource {
346 pp_log: PpLog,
347 name: Arc<str>,
348 /// Only used by [`CaptureMode::Gpu`]'s [`DxgiCaptureSource::emit_frame`]
349 /// path, to build each tick's fresh per-emission composite texture —
350 /// unused after construction in [`CaptureMode::Cpu`], but harmless to
351 /// hold either way (one extra COM reference, same device already
352 /// owns).
353 device: ID3D11Device,
354 context: ID3D11DeviceContext,
355 units: Vec<CaptureUnit>,
356 /// The final composite image's dimensions — `rect.width`/`rect.height`
357 /// under [`CaptureArea::Region`], the single output's own resolution
358 /// under [`CaptureArea::Output`].
359 width: u32,
360 height: u32,
361 gpu_mode: bool,
362 include_cursor: bool,
363 cursor_shape: Option<CursorShape>,
364 /// The cursor's last known position/visibility — *not*
365 /// `DXGI_OUTDUPL_FRAME_INFO::PointerPosition` read fresh every call.
366 /// Per Microsoft's own Desktop Duplication sample, that field is only
367 /// actually refreshed on a call where `LastMouseUpdateTime != 0` (the
368 /// mouse itself changed on *this* call) — on any other call its
369 /// contents aren't meaningful. Updated only when `LastMouseUpdateTime
370 /// != 0` and composited fresh onto every emitted frame (independent
371 /// of whether the desktop image itself changed that tick), so a
372 /// moving cursor over an otherwise-static screen still shows up.
373 cursor_position: POINT,
374 cursor_visible: bool,
375 /// The most recently captured composite desktop image, CPU-side —
376 /// plain, not pool-backed (never shared/pushed directly downstream;
377 /// see `run`'s own emit step, which copies out of this into a fresh
378 /// pooled frame every tick). Each unit's own crop is written directly
379 /// into its correct offset here as it's polled (see `poll_capture`),
380 /// so this is always the up-to-date composite, not something
381 /// assembled at emit time; re-copied from as-is on every tick where
382 /// nothing new arrived, which is what makes this element emit at a
383 /// constant rate rather than only on real changes. Only under
384 /// [`CaptureMode::Cpu`] — `None` under [`CaptureMode::Gpu`], which
385 /// composites straight from each unit's own `staging_texture` at
386 /// emit time instead (see [`DxgiCaptureSource::emit_frame_gpu`]).
387 staging: Option<ffmpeg::frame::Video>,
388 /// See [`DxgiCaptureOptions::fps`] — kept alongside `frame_interval`
389 /// so [`DxgiCaptureSource::time_base`] doesn't have to recover it from
390 /// a `Duration`.
391 fps: i32,
392 /// `1 / fps`.
393 frame_interval: Duration,
394 /// This element's `pts` tick counter — one per *emitted* frame (see
395 /// [`DxgiCaptureSource::time_base`]'s own docs), not per real capture.
396 frame_index: i64,
397 pad: SrcPad,
398 /// Reused across every emitted frame — see [`UnboundObjectPool`]'s
399 /// docs. Pre-sized to `width`/`height` up front, same reasoning as
400 /// `Scaler`'s own pool.
401 pool: UnboundObjectPool<ffmpeg::frame::Video>,
402}
403
404// SAFETY: every D3D11/DXGI handle here is a `windows-rs` COM interface
405// wrapper — thread-safe to hand off (refcounting is interlocked), and
406// `&mut self` on every method that touches them (mirrors `D3d12vaDecoder`/
407// `Scaler`'s own reasoning) already rules out concurrent access from
408// multiple threads.
409unsafe impl Send for DxgiCaptureSource {}
410
411impl DxgiCaptureSource {
412 /// Opens whichever output(s) [`DxgiCaptureOptions::area`] resolves to
413 /// and starts duplicating them. Returns the element alongside the
414 /// captured composite's actual `(width, height)` — what the caller
415 /// needs to build a matching downstream
416 /// [`crate::elements::Scaler`]/[`crate::elements::Pacer`], same
417 /// pattern as [`crate::elements::RtspSource::open`] returning stream
418 /// info — plus, under [`CaptureMode::Gpu`], the `ID3D11Device` this
419 /// capture was opened on (`None` under [`CaptureMode::Cpu`], where
420 /// nothing downstream needs to share it). This is always built from
421 /// whichever adapter `area` actually resolves to — see
422 /// [`CaptureMode::Gpu`]'s own docs on why callers should build every
423 /// other D3D11 element sharing this capture from the returned device
424 /// rather than a separately-created one.
425 pub fn open(
426 name: impl Into<String>,
427 options: DxgiCaptureOptions,
428 ) -> std::result::Result<(Self, u32, u32, Option<ID3D11Device>), DxgiCaptureSourceError> {
429 let name: Arc<str> = name.into().into();
430 let pp_log = element_pp_log(ElementType::DxgiCaptureSource, &name, None);
431
432 let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }?;
433 let gpu_mode = matches!(options.capture_mode, CaptureMode::Gpu);
434 // `CaptureMode::Gpu` has no `include_cursor` field at all (see its
435 // own docs) — nothing to extract there, so `false` unconditionally.
436 let include_cursor = match &options.capture_mode {
437 CaptureMode::Cpu { include_cursor } => *include_cursor,
438 CaptureMode::Gpu => false,
439 };
440
441 let (targets, requested) = resolve_area(&factory, &options.area)?;
442 if include_cursor && targets.len() > 1 {
443 return Err(DxgiCaptureSourceError::CursorUnsupportedForRegion);
444 }
445 let width = (requested.right - requested.left) as u32;
446 let height = (requested.bottom - requested.top) as u32;
447
448 // Always built from `area`'s own adapter (every target shares
449 // one — `resolve_area` already checked), `Cpu` and `Gpu` alike —
450 // see `CaptureMode::Gpu`'s own docs on why this element is the
451 // sole place that resolves "which adapter", rather than trusting
452 // (and validating) a caller-supplied device.
453 let adapter = &targets[0].0;
454 let mut device: Option<ID3D11Device> = None;
455 let mut context: Option<ID3D11DeviceContext> = None;
456 unsafe {
457 D3D11CreateDevice(
458 &adapter.cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?,
459 D3D_DRIVER_TYPE_UNKNOWN,
460 HMODULE::default(),
461 D3D11_CREATE_DEVICE_FLAG(0),
462 None,
463 D3D11_SDK_VERSION,
464 Some(&mut device),
465 None,
466 Some(&mut context),
467 )?;
468 }
469 let device = device.expect("D3D11CreateDevice succeeded without producing a device");
470 let context = context.expect("D3D11CreateDevice succeeded without producing a context");
471 // Cloned before `device` moves into `Self` below — the only copy
472 // handed back to the caller (a COM ref-count bump, not a deep
473 // copy).
474 let returned_device = gpu_mode.then(|| device.clone());
475 let dxgi_device: IDXGIDevice = device.cast()?;
476
477 let mut units = Vec::with_capacity(targets.len());
478 for (_, output, desktop_rect) in &targets {
479 let duplication = unsafe { output.DuplicateOutput(&dxgi_device) }?;
480 let desc = unsafe { duplication.GetDesc() };
481 let unit_width = desc.ModeDesc.Width;
482 let unit_height = desc.ModeDesc.Height;
483
484 // The overlap between this output's own desktop rectangle
485 // and the requested one, expressed two ways: as a source box
486 // local to this output's own texture, and as a destination
487 // offset into the composite. `resolve_area` already
488 // guarantees a non-empty overlap for every target it
489 // returns.
490 let overlap_left = desktop_rect.left.max(requested.left);
491 let overlap_top = desktop_rect.top.max(requested.top);
492 let overlap_right = desktop_rect.right.min(requested.right);
493 let overlap_bottom = desktop_rect.bottom.min(requested.bottom);
494 let source_box = D3D11_BOX {
495 left: (overlap_left - desktop_rect.left) as u32,
496 top: (overlap_top - desktop_rect.top) as u32,
497 front: 0,
498 right: (overlap_right - desktop_rect.left) as u32,
499 bottom: (overlap_bottom - desktop_rect.top) as u32,
500 back: 1,
501 };
502 let dest_x = (overlap_left - requested.left) as u32;
503 let dest_y = (overlap_top - requested.top) as u32;
504
505 // Cpu: CPU-readable staging texture, `Map`ped every real
506 // capture (see `poll_capture`). Gpu: GPU-only — this
507 // per-output texture is only ever a `CopySubresourceRegion`
508 // source, never sampled directly, so unlike the composite
509 // `emit_frame_gpu` builds, no bind flags are needed here.
510 let staging_desc = D3D11_TEXTURE2D_DESC {
511 Width: unit_width,
512 Height: unit_height,
513 MipLevels: 1,
514 ArraySize: 1,
515 Format: DXGI_FORMAT_B8G8R8A8_UNORM,
516 SampleDesc: DXGI_SAMPLE_DESC {
517 Count: 1,
518 Quality: 0,
519 },
520 Usage: if gpu_mode {
521 D3D11_USAGE_DEFAULT
522 } else {
523 D3D11_USAGE_STAGING
524 },
525 BindFlags: D3D11_BIND_FLAG(0).0 as u32,
526 CPUAccessFlags: if gpu_mode {
527 0
528 } else {
529 D3D11_CPU_ACCESS_READ.0 as u32
530 },
531 MiscFlags: 0,
532 };
533 let mut staging_texture: Option<ID3D11Texture2D> = None;
534 unsafe { device.CreateTexture2D(&staging_desc, None, Some(&mut staging_texture)) }?;
535 let staging_texture =
536 staging_texture.expect("CreateTexture2D succeeded without producing a texture");
537
538 units.push(CaptureUnit {
539 duplication,
540 staging_texture,
541 source_box,
542 dest_x,
543 dest_y,
544 has_captured: false,
545 });
546 }
547
548 let pad = SrcPad::new(format!("{name}_src"));
549 // Gpu: only the small CPU-side `AVFrame` wrapper is ever pooled
550 // (`ffmpeg::frame::Video::empty` — same as `D3d11Upload`'s own
551 // pool); the GPU texture itself is a fresh allocation every
552 // `emit_frame` call (see that method's own docs on why). Cpu:
553 // pre-sized real `Pixel::BGRA` CPU buffers, as before.
554 let pool = if gpu_mode {
555 UnboundObjectPool::new(0, ffmpeg::frame::Video::empty, |_| {})
556 } else {
557 UnboundObjectPool::new(
558 0,
559 move || ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height),
560 |_| {},
561 )
562 };
563 let staging = (!gpu_mode)
564 .then(|| ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, width, height));
565
566 let fps = options.fps.max(1); // a `0` fps is nonsensical; treat it as 1 rather than dividing by zero
567 pp_info!(
568 pp_log: &pp_log,
569 "opened: {}x{} composite from {} output(s), include_cursor={}, fps={}, gpu_mode={}",
570 width,
571 height,
572 units.len(),
573 include_cursor,
574 fps,
575 gpu_mode
576 );
577
578 Ok((
579 Self {
580 name,
581 pp_log,
582 device,
583 context,
584 units,
585 width,
586 height,
587 gpu_mode,
588 include_cursor,
589 cursor_shape: None,
590 cursor_position: POINT::default(),
591 cursor_visible: false,
592 staging,
593 fps: fps as i32,
594 frame_interval: Duration::from_secs_f64(1.0 / fps as f64),
595 frame_index: 0,
596 pad,
597 pool,
598 },
599 width,
600 height,
601 returned_device,
602 ))
603 }
604
605 /// The unit each emitted frame's `pts` is expressed in — what you
606 /// need to construct a matching [`crate::elements::Pacer`]. `1 /
607 /// fps`, same convention as [`crate::elements::TestVideoSource::time_base`].
608 pub fn time_base(&self) -> ffmpeg::Rational {
609 ffmpeg::Rational::new(1, self.fps)
610 }
611
612 /// Whether every contributing output has captured at least one real
613 /// image yet — see [`CaptureUnit::has_captured`]'s own docs.
614 fn all_captured(&self) -> bool {
615 self.units.iter().all(|unit| unit.has_captured)
616 }
617
618 /// Refreshes `self.cursor_shape` from `unit_index`'s duplication
619 /// interface's current pointer shape buffer. Only called when
620 /// `DXGI_OUTDUPL_FRAME_INFO::PointerShapeBufferSize > 0` — i.e. the
621 /// shape actually changed since the last call. Only ever called with
622 /// `unit_index == 0`: `open` rejects `include_cursor` whenever more
623 /// than one unit exists (see [`CaptureArea::Region`]'s own docs).
624 fn refresh_cursor_shape(
625 &mut self,
626 unit_index: usize,
627 buffer_size: usize,
628 ) -> std::result::Result<(), windows::core::Error> {
629 let mut buffer = vec![0u8; buffer_size];
630 let mut required = 0u32;
631 let mut info = DXGI_OUTDUPL_POINTER_SHAPE_INFO::default();
632 unsafe {
633 self.units[unit_index].duplication.GetFramePointerShape(
634 buffer.len() as u32,
635 buffer.as_mut_ptr() as *mut c_void,
636 &mut required,
637 &mut info,
638 )?;
639 }
640 buffer.truncate(required as usize);
641 self.cursor_shape = Some(CursorShape {
642 kind: info.Type,
643 width: info.Width,
644 height: info.Height,
645 pitch: info.Pitch,
646 data: buffer,
647 });
648 Ok(())
649 }
650
651 /// Tries once to capture a new image from every contributing output,
652 /// within `timeout_ms` total — Desktop Duplication has no "wait on
653 /// any of these" primitive, so `timeout_ms` is split evenly across
654 /// `self.units` (more outputs means a longer total `poll_capture`
655 /// call for the same per-unit responsiveness, still bounded overall
656 /// by `POLL_GRANULARITY` same as the single-output case always was).
657 /// Under [`CaptureMode::Cpu`], each unit that captured a new image
658 /// writes its own crop directly into `self.staging` at its own
659 /// composite offset. Always refreshes the cached cursor
660 /// position/shape (see their own docs) regardless, since the mouse
661 /// can move independently of the desktop image. A
662 /// `DXGI_ERROR_WAIT_TIMEOUT` on any one unit (nothing changed within
663 /// its share of `timeout_ms`) is not an error — that unit is simply
664 /// unchanged this call.
665 fn poll_capture(&mut self, timeout_ms: u32) -> std::result::Result<(), DxgiCaptureSourceError> {
666 let per_unit_timeout = timeout_ms / self.units.len() as u32;
667 for index in 0..self.units.len() {
668 let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
669 let mut resource: Option<IDXGIResource> = None;
670 let acquire = unsafe {
671 self.units[index].duplication.AcquireNextFrame(
672 per_unit_timeout,
673 &mut info,
674 &mut resource,
675 )
676 };
677 let resource = match acquire {
678 Ok(()) => resource.expect("AcquireNextFrame succeeded without a resource"),
679 Err(error) if error.code() == DXGI_ERROR_WAIT_TIMEOUT => continue,
680 Err(error) if error.code() == DXGI_ERROR_ACCESS_LOST => {
681 return Err(DxgiCaptureSourceError::AccessLost);
682 }
683 Err(error) => return Err(error.into()),
684 };
685
686 if self.include_cursor {
687 if info.PointerShapeBufferSize > 0 {
688 self.refresh_cursor_shape(index, info.PointerShapeBufferSize as usize)?;
689 }
690 // See `cursor_position`/`cursor_visible`'s own docs: only
691 // trust `info.PointerPosition` on the call where the
692 // mouse itself actually changed.
693 if info.LastMouseUpdateTime != 0 {
694 self.cursor_position = info.PointerPosition.Position;
695 self.cursor_visible = info.PointerPosition.Visible.as_bool();
696 }
697 }
698
699 // `AcquireNextFrame` succeeds not just when the desktop image
700 // itself changed, but also on a *cursor-only* update (the
701 // pointer moved/blinked with the picture underneath it
702 // untouched) — `AccumulatedFrames == 0` is how DXGI signals
703 // that case (see Microsoft's own Desktop Duplication sample).
704 // The cursor position was already refreshed above
705 // regardless; there's just no new *image* to copy out, so
706 // release and move to the next unit.
707 if info.AccumulatedFrames == 0 {
708 unsafe { self.units[index].duplication.ReleaseFrame() }?;
709 continue;
710 }
711
712 // Every fallible step here (the two `cast`s, the copy itself)
713 // must still release DXGI's own frame lock on the way out —
714 // an early `?` before `ReleaseFrame()` would leave this unit
715 // unable to `AcquireNextFrame` again until it's torn down
716 // entirely, so the copy's own result is captured instead of
717 // propagated directly.
718 let copy_result: std::result::Result<(), DxgiCaptureSourceError> = (|| {
719 let texture: ID3D11Texture2D = resource.cast()?;
720 unsafe {
721 self.context.CopyResource(
722 &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
723 &texture.cast::<ID3D11Resource>()?,
724 );
725 }
726 Ok(())
727 })();
728
729 // Release DXGI's own frame as soon as we've copied it out,
730 // rather than holding it while we map/read (Cpu mode) the
731 // (independent) staging copy below — and unconditionally,
732 // even if the copy above failed.
733 let release_result = unsafe { self.units[index].duplication.ReleaseFrame() };
734 copy_result?;
735 release_result?;
736
737 if self.gpu_mode {
738 // No `Map`/CPU copy at all — `staging_texture` itself
739 // *is* this unit's latest capture; `emit_frame_gpu` reads
740 // straight from it. See `CaptureMode::Gpu`'s own docs.
741 self.units[index].has_captured = true;
742 continue;
743 }
744
745 // Cpu mode: Map this unit's own staging texture and copy
746 // just its `source_box` crop into the shared composite
747 // buffer at `dest_x`/`dest_y` — no separate composite GPU
748 // texture needed, the crop lands directly in CPU memory at
749 // its final position.
750 let mut mapped = Default::default();
751 unsafe {
752 self.context.Map(
753 &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
754 0,
755 D3D11_MAP_READ,
756 0,
757 Some(&mut mapped),
758 )?;
759 }
760 {
761 let unit = &self.units[index];
762 let box_ = unit.source_box;
763 let crop_width = (box_.right - box_.left) as usize;
764 let crop_height = (box_.bottom - box_.top) as usize;
765 let row_bytes = crop_width * 4;
766 let staging = self
767 .staging
768 .as_mut()
769 .expect("CaptureMode::Cpu always has a staging buffer");
770 let dst_stride = staging.stride(0);
771 let dst = staging.data_mut(0);
772 for row in 0..crop_height {
773 let src_row = box_.top as usize + row;
774 let src = unsafe {
775 std::slice::from_raw_parts(
776 (mapped.pData as *const u8)
777 .add(src_row * mapped.RowPitch as usize + box_.left as usize * 4),
778 row_bytes,
779 )
780 };
781 let dst_row = unit.dest_y as usize + row;
782 let dst_col = unit.dest_x as usize * 4;
783 dst[dst_row * dst_stride + dst_col..dst_row * dst_stride + dst_col + row_bytes]
784 .copy_from_slice(src);
785 }
786 }
787 unsafe {
788 self.context.Unmap(
789 &self.units[index].staging_texture.cast::<ID3D11Resource>()?,
790 0,
791 );
792 }
793 self.units[index].has_captured = true;
794 }
795 Ok(())
796 }
797
798 /// Builds the next frame to push — the unit of work `run` does once
799 /// per emission tick, real change or repeat — and stamps the next
800 /// `pts` and this source's fixed color description. Dispatches to
801 /// [`DxgiCaptureSource::emit_frame_cpu`] or
802 /// [`DxgiCaptureSource::emit_frame_gpu`] depending on `self.gpu_mode`.
803 ///
804 /// Both modes produce BGRA, so both get the same description, and it
805 /// matches what [`crate::elements::D3d11VideoCompositor`] already stamps
806 /// on its own BGRA output: `Space::RGB` because these are RGB samples
807 /// with no luma/chroma matrix applied, and `Range::JPEG` because desktop
808 /// pixels are full-range 0-255, not studio-swing.
809 ///
810 /// Downstream consumers read this rather than guessing:
811 /// `D3d11VideoCompositor` uses it to pick its NV12 conversion matrix,
812 /// and [`crate::elements::D3d11NvencEncoder`] forwards it. Note it does
813 /// **not** change what a BGRA-input NVENC recording is tagged with —
814 /// NVENC converts RGB to YUV inside its own encode block with a fixed
815 /// matrix and tags the bitstream to match what it actually did, which
816 /// is why that path stays self-consistent either way.
817 fn emit_frame(
818 &mut self,
819 ) -> std::result::Result<
820 crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video>,
821 DxgiCaptureSourceError,
822 > {
823 let mut frame = if self.gpu_mode {
824 self.emit_frame_gpu()?
825 } else {
826 self.emit_frame_cpu()
827 };
828 frame.set_pts(Some(self.frame_index));
829 frame.set_color_space(ffmpeg::color::Space::RGB);
830 frame.set_color_range(ffmpeg::color::Range::JPEG);
831 self.frame_index += 1;
832 Ok(frame)
833 }
834
835 /// Copies `self.staging` (the latest captured composite image,
836 /// however stale — already assembled from every unit's own crop by
837 /// `poll_capture`) into a fresh pooled CPU frame, compositing the
838 /// cursor onto that copy if enabled. Copying instead of sharing
839 /// `self.staging` directly is what lets each emitted frame carry its
840 /// own distinct, correctly-incrementing `pts` (stamped by the
841 /// caller, `emit_frame`) even when several emissions in a row show
842 /// the same content — an `Arc`-shared frame can't have its `pts`
843 /// safely rewritten in place once downstream might already hold a
844 /// clone of it.
845 fn emit_frame_cpu(&mut self) -> crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video> {
846 let mut frame = self.pool.get();
847 let staging = self
848 .staging
849 .as_ref()
850 .expect("CaptureMode::Cpu always has a staging buffer");
851 {
852 let dst_stride = frame.stride(0);
853 let src_stride = staging.stride(0);
854 let row_bytes = self.width as usize * 4;
855 let src = staging.data(0);
856 let dst = frame.data_mut(0);
857 for row in 0..self.height as usize {
858 dst[row * dst_stride..row * dst_stride + row_bytes]
859 .copy_from_slice(&src[row * src_stride..row * src_stride + row_bytes]);
860 }
861 if self.include_cursor
862 && self.cursor_visible
863 && let Some(shape) = &self.cursor_shape
864 {
865 composite_cursor(
866 dst,
867 dst_stride,
868 self.width,
869 self.height,
870 self.cursor_position.x,
871 self.cursor_position.y,
872 shape,
873 );
874 }
875 }
876 frame
877 }
878
879 /// `CaptureMode::Gpu`'s equivalent of [`DxgiCaptureSource::emit_frame_cpu`]:
880 /// builds a fresh composite `ID3D11Texture2D` (`self.width` x
881 /// `self.height`) and `CopySubresourceRegion`s every unit's own
882 /// `source_box` crop into it at that unit's `dest_x`/`dest_y` — one
883 /// GPU-side copy per contributing output, same reasoning
884 /// `D3d11Upload::upload` documents for allocating fresh every call
885 /// rather than reusing one texture: each unit's own `staging_texture`
886 /// gets overwritten by its next real capture, so a frame already
887 /// pushed downstream needs its own, independently stable copy of
888 /// what it was capturing at push time. Wraps the fresh texture as a
889 /// `Pixel::D3D11` frame via [`wrap_d3d11_texture`], reusing the
890 /// pooled `AVFrame` wrapper (the pool built by `open` for
891 /// `CaptureMode::Gpu` only holds these small wrappers, not GPU
892 /// memory — see that pool's own construction site).
893 fn emit_frame_gpu(
894 &mut self,
895 ) -> std::result::Result<
896 crate::pool::UnboundObjectPoolRef<ffmpeg::frame::Video>,
897 DxgiCaptureSourceError,
898 > {
899 let desc = D3D11_TEXTURE2D_DESC {
900 Width: self.width,
901 Height: self.height,
902 MipLevels: 1,
903 ArraySize: 1,
904 Format: DXGI_FORMAT_B8G8R8A8_UNORM,
905 SampleDesc: DXGI_SAMPLE_DESC {
906 Count: 1,
907 Quality: 0,
908 },
909 Usage: D3D11_USAGE_DEFAULT,
910 BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
911 CPUAccessFlags: 0,
912 MiscFlags: 0,
913 };
914 let mut texture: Option<ID3D11Texture2D> = None;
915 unsafe {
916 self.device
917 .CreateTexture2D(&desc, None, Some(&mut texture))?;
918 }
919 let texture = texture.expect("CreateTexture2D succeeded without producing a texture");
920 let dst_resource: ID3D11Resource = texture.cast()?;
921 for unit in &self.units {
922 let src_resource: ID3D11Resource = unit.staging_texture.cast()?;
923 let box_ = unit.source_box;
924 unsafe {
925 self.context.CopySubresourceRegion(
926 &dst_resource,
927 0,
928 unit.dest_x,
929 unit.dest_y,
930 0,
931 &src_resource,
932 0,
933 Some(&box_ as *const D3D11_BOX),
934 );
935 }
936 }
937
938 let mut frame = self.pool.get();
939 // Overwrites the pooled slot's previous contents in place —
940 // `ffmpeg::frame::Video`'s own `Drop` runs on whatever was there
941 // before, releasing that frame's GPU texture right here (same
942 // pattern `D3d11Upload::consume` documents).
943 *frame = wrap_d3d11_texture(texture, self.width, self.height);
944 Ok(frame)
945 }
946}
947
948impl Element for DxgiCaptureSource {
949 fn name(&self) -> Arc<str> {
950 self.name.clone()
951 }
952
953 fn element_type(&self) -> ElementType {
954 ElementType::DxgiCaptureSource
955 }
956
957 fn pp_log(&self) -> &PpLog {
958 &self.pp_log
959 }
960
961 fn pp_log_mut(&mut self) -> &mut PpLog {
962 &mut self.pp_log
963 }
964}
965
966impl Source for DxgiCaptureSource {
967 fn src_pads(&mut self) -> &mut [SrcPad] {
968 std::slice::from_mut(&mut self.pad)
969 }
970}
971
972impl SourceElement for DxgiCaptureSource {
973 fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
974 pp_info!(self, "started");
975 let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
976 loop {
977 let outcome = drain_control(control, self, bus)?;
978 if outcome.stopped {
979 pp_info!(self, "stopped");
980 return Ok(());
981 }
982 if outcome.paused_for > Duration::ZERO {
983 schedule.resume_after_pause(outcome.paused_for, Instant::now());
984 }
985
986 let poll_timeout = schedule.remaining(Instant::now()).min(POLL_GRANULARITY);
987 if let Err(error) = self.poll_capture(poll_timeout.as_millis() as u32) {
988 pp_error!(self, "capture failed: {error}");
989 return Err(error.into());
990 }
991
992 if !schedule.is_due(Instant::now()) {
993 continue;
994 }
995
996 if !self.all_captured() {
997 // Still advance even though there's nothing to emit this
998 // tick — otherwise `next_due` sits in the past and the
999 // next iteration's `poll_timeout` above is zero, busy-looping
1000 // instead of waiting for the next tick.
1001 schedule.advance_after_tick(Instant::now());
1002 continue; // nothing real captured yet — nothing to emit
1003 }
1004 let frame = match self.emit_frame() {
1005 Ok(frame) => frame,
1006 Err(error) => {
1007 pp_error!(self, "emit_frame failed: {error}");
1008 return Err(error.into());
1009 }
1010 };
1011 if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(frame))) {
1012 bus.post(
1013 &self.pp_log,
1014 BusEvent::Error {
1015 element_type: ElementType::DxgiCaptureSource,
1016 name: self.name.clone(),
1017 error,
1018 },
1019 );
1020 }
1021 // Advance only now that this tick's own work (emit + push,
1022 // which a slow downstream/GPU readback can stretch
1023 // arbitrarily) is done — see `TestVideoSource::run`'s
1024 // identical correction for why the placement matters.
1025 schedule.advance_after_tick(Instant::now());
1026 }
1027 }
1028
1029 fn seek(&mut self, _target: Duration) -> Result<Duration> {
1030 Err(DxgiCaptureSourceError::SeekUnsupported.into())
1031 }
1032}
1033
1034fn pick_output(
1035 factory: &IDXGIFactory1,
1036 output_index: u32,
1037) -> std::result::Result<(IDXGIAdapter1, IDXGIOutput1), DxgiCaptureSourceError> {
1038 let mut remaining = output_index;
1039 let mut adapter_index = 0u32;
1040 loop {
1041 let adapter = match unsafe { factory.EnumAdapters1(adapter_index) } {
1042 Ok(adapter) => adapter,
1043 Err(_) => return Err(DxgiCaptureSourceError::NoSuchOutput(output_index)),
1044 };
1045 let mut output_i = 0u32;
1046 loop {
1047 let output = match unsafe { adapter.EnumOutputs(output_i) } {
1048 Ok(output) => output,
1049 Err(_) => break,
1050 };
1051 if remaining == 0 {
1052 let output1: IDXGIOutput1 = output.cast()?;
1053 return Ok((adapter, output1));
1054 }
1055 remaining -= 1;
1056 output_i += 1;
1057 }
1058 adapter_index += 1;
1059 }
1060}
1061
1062/// One output resolved by [`resolve_area`]: its own adapter, the output
1063/// itself, and its absolute-desktop `DesktopCoordinates`.
1064type ResolvedOutput = (IDXGIAdapter1, IDXGIOutput1, RECT);
1065
1066/// Resolves `area` into the concrete output(s) it captures from, plus the
1067/// absolute-desktop rectangle actually requested. [`CaptureArea::Output`]
1068/// always resolves to exactly one target (that output's own
1069/// `DesktopCoordinates` doubles as the requested rectangle — the whole
1070/// monitor). [`CaptureArea::Region`] resolves to every output whose own
1071/// desktop rectangle intersects the requested one —
1072/// [`DxgiCaptureSourceError::RegionOutsideDesktop`] if none do — and fails
1073/// with [`DxgiCaptureSourceError::RegionSpansMultipleAdapters`] if those
1074/// outputs aren't all on the same adapter, checked here before
1075/// [`DxgiCaptureSource::open`] opens any duplication.
1076fn resolve_area(
1077 factory: &IDXGIFactory1,
1078 area: &CaptureArea,
1079) -> std::result::Result<(Vec<ResolvedOutput>, RECT), DxgiCaptureSourceError> {
1080 match *area {
1081 CaptureArea::Output { output_index } => {
1082 let (adapter, output) = pick_output(factory, output_index)?;
1083 let desktop_rect = unsafe {
1084 output
1085 .cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput>()?
1086 .GetDesc()
1087 }?
1088 .DesktopCoordinates;
1089 Ok((vec![(adapter, output, desktop_rect)], desktop_rect))
1090 }
1091 CaptureArea::Region(rect) => {
1092 let requested = RECT {
1093 left: rect.x,
1094 top: rect.y,
1095 right: rect.x + rect.width as i32,
1096 bottom: rect.y + rect.height as i32,
1097 };
1098 let mut targets = Vec::new();
1099 let mut adapter_index = 0u32;
1100 loop {
1101 let adapter = match unsafe { factory.EnumAdapters1(adapter_index) } {
1102 Ok(adapter) => adapter,
1103 Err(_) => break,
1104 };
1105 let mut output_i = 0u32;
1106 loop {
1107 let output = match unsafe { adapter.EnumOutputs(output_i) } {
1108 Ok(output) => output,
1109 Err(_) => break,
1110 };
1111 let output1: IDXGIOutput1 = output.cast()?;
1112 let desktop_rect = unsafe {
1113 output1
1114 .cast::<windows::Win32::Graphics::Dxgi::IDXGIOutput>()?
1115 .GetDesc()
1116 }?
1117 .DesktopCoordinates;
1118 let intersects = desktop_rect.left < requested.right
1119 && desktop_rect.right > requested.left
1120 && desktop_rect.top < requested.bottom
1121 && desktop_rect.bottom > requested.top;
1122 if intersects {
1123 targets.push((adapter.clone(), output1, desktop_rect));
1124 }
1125 output_i += 1;
1126 }
1127 adapter_index += 1;
1128 }
1129 if targets.is_empty() {
1130 return Err(DxgiCaptureSourceError::RegionOutsideDesktop(rect));
1131 }
1132 let first_luid = unsafe {
1133 targets[0]
1134 .0
1135 .cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?
1136 .GetDesc()
1137 }?
1138 .AdapterLuid;
1139 for (adapter, _, _) in &targets[1..] {
1140 let luid = unsafe {
1141 adapter
1142 .cast::<windows::Win32::Graphics::Dxgi::IDXGIAdapter>()?
1143 .GetDesc()
1144 }?
1145 .AdapterLuid;
1146 if (luid.LowPart, luid.HighPart) != (first_luid.LowPart, first_luid.HighPart) {
1147 return Err(DxgiCaptureSourceError::RegionSpansMultipleAdapters);
1148 }
1149 }
1150 Ok((targets, requested))
1151 }
1152 }
1153}
1154
1155/// Blends [`CursorShape`] onto `dst` (a `Pixel::BGRA` plane, `dst_stride`
1156/// bytes per row, `dst_width`x`dst_height` pixels) at `(pos_x, pos_y)`,
1157/// clipped to `dst`'s bounds — the position can legitimately fall partly
1158/// outside this output's captured region on a multi-monitor setup.
1159/// Implements the three DXGI pointer shape kinds per MSDN's
1160/// `DXGI_OUTDUPL_POINTER_SHAPE_TYPE` docs. A pure function over byte
1161/// buffers (no D3D calls) so it's unit-testable without a live capture.
1162fn composite_cursor(
1163 dst: &mut [u8],
1164 dst_stride: usize,
1165 dst_width: u32,
1166 dst_height: u32,
1167 pos_x: i32,
1168 pos_y: i32,
1169 shape: &CursorShape,
1170) {
1171 if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME.0 as u32 {
1172 let mask_height = shape.height / 2;
1173 for row in 0..mask_height {
1174 for col in 0..shape.width {
1175 let byte_col = (col / 8) as usize;
1176 let bit = 7 - (col % 8);
1177 let and_byte = shape.data[row as usize * shape.pitch as usize + byte_col];
1178 let xor_byte =
1179 shape.data[(mask_height + row) as usize * shape.pitch as usize + byte_col];
1180 let and_bit = (and_byte >> bit) & 1;
1181 let xor_bit = (xor_byte >> bit) & 1;
1182 let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1183 if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1184 continue;
1185 }
1186 let offset = y as usize * dst_stride + x as usize * 4;
1187 match (and_bit, xor_bit) {
1188 (0, 0) => {
1189 dst[offset] = 0;
1190 dst[offset + 1] = 0;
1191 dst[offset + 2] = 0;
1192 dst[offset + 3] = 255;
1193 }
1194 (0, 1) => {
1195 dst[offset] = 255;
1196 dst[offset + 1] = 255;
1197 dst[offset + 2] = 255;
1198 dst[offset + 3] = 255;
1199 }
1200 (1, 0) => {}
1201 _ => {
1202 dst[offset] ^= 0xFF;
1203 dst[offset + 1] ^= 0xFF;
1204 dst[offset + 2] ^= 0xFF;
1205 }
1206 }
1207 }
1208 }
1209 } else if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR.0 as u32 {
1210 for row in 0..shape.height {
1211 for col in 0..shape.width {
1212 let idx = row as usize * shape.pitch as usize + col as usize * 4;
1213 let (b, g, r, a) = (
1214 shape.data[idx],
1215 shape.data[idx + 1],
1216 shape.data[idx + 2],
1217 shape.data[idx + 3],
1218 );
1219 let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1220 if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1221 continue;
1222 }
1223 let offset = y as usize * dst_stride + x as usize * 4;
1224 let inv = 255 - a as u32;
1225 dst[offset] = ((b as u32 * a as u32 + dst[offset] as u32 * inv) / 255) as u8;
1226 dst[offset + 1] =
1227 ((g as u32 * a as u32 + dst[offset + 1] as u32 * inv) / 255) as u8;
1228 dst[offset + 2] =
1229 ((r as u32 * a as u32 + dst[offset + 2] as u32 * inv) / 255) as u8;
1230 dst[offset + 3] = 255;
1231 }
1232 }
1233 } else if shape.kind == DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR.0 as u32 {
1234 for row in 0..shape.height {
1235 for col in 0..shape.width {
1236 let idx = row as usize * shape.pitch as usize + col as usize * 4;
1237 let (b, g, r, a) = (
1238 shape.data[idx],
1239 shape.data[idx + 1],
1240 shape.data[idx + 2],
1241 shape.data[idx + 3],
1242 );
1243 let (x, y) = (pos_x + col as i32, pos_y + row as i32);
1244 if x < 0 || y < 0 || x as u32 >= dst_width || y as u32 >= dst_height {
1245 continue;
1246 }
1247 let offset = y as usize * dst_stride + x as usize * 4;
1248 if a == 0xFF {
1249 dst[offset] ^= b;
1250 dst[offset + 1] ^= g;
1251 dst[offset + 2] ^= r;
1252 } else {
1253 dst[offset] = b;
1254 dst[offset + 1] = g;
1255 dst[offset + 2] = r;
1256 dst[offset + 3] = 255;
1257 }
1258 }
1259 }
1260 }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::*;
1266
1267 fn blank_frame(width: u32, height: u32) -> (Vec<u8>, usize) {
1268 let stride = width as usize * 4;
1269 (vec![0u8; stride * height as usize], stride)
1270 }
1271
1272 #[test]
1273 fn monochrome_cursor_draws_black_white_and_leaves_transparent_alone() {
1274 let (mut dst, stride) = blank_frame(4, 4);
1275 // 2x2 mask: AND=0/XOR=0 (black), AND=0/XOR=1 (white),
1276 // AND=1/XOR=0 (unchanged), AND=1/XOR=1 (invert).
1277 // AND row: bits 0,0,1,1 -> 0b00110000 in the top nibble (MSB first)
1278 // XOR row: bits 0,1,0,1 -> 0b01010000
1279 let and_row = 0b0011_0000u8;
1280 let xor_row = 0b0101_0000u8;
1281 dst[stride + 2 * 4] = 200; // pre-existing pixel at (2,1) to check invert
1282 dst[stride + 2 * 4 + 1] = 100;
1283 dst[stride + 2 * 4 + 2] = 50;
1284 let shape = CursorShape {
1285 kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MONOCHROME.0 as u32,
1286 width: 4,
1287 height: 4, // 2 rows AND + 2 rows XOR
1288 pitch: 1,
1289 data: vec![and_row, and_row, xor_row, xor_row],
1290 };
1291 composite_cursor(&mut dst, stride, 4, 4, 0, 0, &shape);
1292
1293 // (0,0): and=0,xor=0 -> black
1294 assert_eq!(&dst[0..4], &[0, 0, 0, 255]);
1295 // (1,0): and=0,xor=1 -> white
1296 assert_eq!(&dst[4..8], &[255, 255, 255, 255]);
1297 // (2,1): and=1,xor=0 -> unchanged (pre-existing pixel)
1298 let off = stride + 2 * 4;
1299 assert_eq!(&dst[off..off + 3], &[200, 100, 50]);
1300 // (3,1): and=1,xor=1 -> inverted from 0 -> 255
1301 let off = stride + 3 * 4;
1302 assert_eq!(&dst[off..off + 3], &[255, 255, 255]);
1303 }
1304
1305 #[test]
1306 fn color_cursor_alpha_blends_over_destination() {
1307 let (mut dst, stride) = blank_frame(2, 1);
1308 dst[0..4].copy_from_slice(&[10, 20, 30, 255]); // dst pixel (0,0)
1309 let shape = CursorShape {
1310 kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_COLOR.0 as u32,
1311 width: 1,
1312 height: 1,
1313 pitch: 4,
1314 data: vec![200, 150, 100, 255], // fully opaque src -> fully replaces
1315 };
1316 composite_cursor(&mut dst, stride, 2, 1, 0, 0, &shape);
1317 assert_eq!(&dst[0..4], &[200, 150, 100, 255]);
1318 }
1319
1320 #[test]
1321 fn masked_color_cursor_xors_when_alpha_is_full_and_replaces_otherwise() {
1322 let (mut dst, stride) = blank_frame(2, 1);
1323 dst[0..4].copy_from_slice(&[0b1010_1010, 0, 0, 255]);
1324 dst[4..8].copy_from_slice(&[1, 2, 3, 255]);
1325 let shape = CursorShape {
1326 kind: DXGI_OUTDUPL_POINTER_SHAPE_TYPE_MASKED_COLOR.0 as u32,
1327 width: 2,
1328 height: 1,
1329 pitch: 8,
1330 data: vec![
1331 0b0101_0101,
1332 0,
1333 0,
1334 0xFF, // xor at (0,0)
1335 77,
1336 88,
1337 99,
1338 0x00, // replace at (1,0)
1339 ],
1340 };
1341 composite_cursor(&mut dst, stride, 2, 1, 0, 0, &shape);
1342 assert_eq!(dst[0], 0b1010_1010 ^ 0b0101_0101);
1343 assert_eq!(&dst[4..8], &[77, 88, 99, 255]);
1344 }
1345
1346 /// Every emitted frame has to describe its own color, in both capture
1347 /// modes: `D3d11VideoCompositor` reads exactly these two fields to pick
1348 /// an NV12 conversion matrix, and leaving them unset makes it fall back
1349 /// to a guess instead of using what this source actually produces.
1350 ///
1351 /// Skips when the machine has no desktop to duplicate (a headless or
1352 /// session-0 runner), since that is a real environment rather than a
1353 /// failure.
1354 #[test]
1355 fn emitted_frames_describe_full_range_rgb_in_both_modes() {
1356 use crate::{buffer::MediaBuffer, elements::AppSink, pipeline::Pipeline};
1357 use std::sync::{Arc, Mutex};
1358
1359 for capture_mode in [
1360 CaptureMode::Cpu {
1361 include_cursor: false,
1362 },
1363 CaptureMode::Gpu,
1364 ] {
1365 let options = DxgiCaptureOptions {
1366 fps: 30,
1367 capture_mode: capture_mode.clone(),
1368 ..DxgiCaptureOptions::default()
1369 };
1370 let Ok((source, _width, _height, _device)) =
1371 DxgiCaptureSource::open("test-capture", options)
1372 else {
1373 eprintln!("skipping {capture_mode:?}: no duplicable desktop on this machine");
1374 continue;
1375 };
1376
1377 let seen: Arc<Mutex<Option<(ffmpeg::color::Space, ffmpeg::color::Range)>>> =
1378 Arc::new(Mutex::new(None));
1379 let recorded = seen.clone();
1380 let sink = AppSink::new("test-capture-sink", move |buf| {
1381 if let MediaBuffer::Video(frame) = buf {
1382 let mut slot = recorded
1383 .lock()
1384 .unwrap_or_else(|poisoned| poisoned.into_inner());
1385 slot.get_or_insert((frame.color_space(), frame.color_range()));
1386 }
1387 Ok(())
1388 });
1389
1390 let pipeline = Pipeline::new("capture-color", source, |source, ctx| {
1391 let branch = ctx.branch().to(Box::new(sink))?;
1392 ctx.attach(source, 0, branch)?;
1393 Ok(())
1394 })
1395 .expect("wiring a capture source to an AppSink should succeed");
1396 pipeline.run();
1397 std::thread::sleep(std::time::Duration::from_millis(300));
1398 pipeline.stop();
1399
1400 let observed = seen
1401 .lock()
1402 .unwrap_or_else(|poisoned| poisoned.into_inner())
1403 .take();
1404 let Some((space, range)) = observed else {
1405 eprintln!("skipping {capture_mode:?}: capture produced no frame in time");
1406 continue;
1407 };
1408 assert_eq!(
1409 space,
1410 ffmpeg::color::Space::RGB,
1411 "{capture_mode:?} must describe its BGRA samples as RGB, not leave them unspecified"
1412 );
1413 assert_eq!(
1414 range,
1415 ffmpeg::color::Range::JPEG,
1416 "{capture_mode:?} must describe desktop pixels as full range"
1417 );
1418 }
1419 }
1420}